using MemberCenter.Web.Models.Admin; using MemberCenter.Application.Abstractions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using OpenIddict.Abstractions; namespace MemberCenter.Web.Controllers.Admin; [Authorize(Policy = "Admin")] [Route("admin/oauth-clients")] public class OAuthClientsController : Controller { private readonly IOpenIddictApplicationManager _applicationManager; private readonly ITenantService _tenantService; public OAuthClientsController( IOpenIddictApplicationManager applicationManager, ITenantService tenantService) { _applicationManager = applicationManager; _tenantService = tenantService; } [HttpGet("")] public async Task Index() { var results = new List(); await foreach (var application in _applicationManager.ListAsync()) { var properties = await _applicationManager.GetPropertiesAsync(application); var usage = properties.TryGetValue("usage", out var usageElement) ? usageElement.GetString() : "tenant_api"; 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), usage = usage, redirect_uris = await _applicationManager.GetRedirectUrisAsync(application) }); } return View(results); } [HttpGet("create")] public async Task Create() { var tenants = await _tenantService.ListAsync(); return View(new OAuthClientFormViewModel { Tenants = tenants }); } [HttpPost("create")] public async Task Create(OAuthClientFormViewModel model) { if (!IsValidUsage(model.Usage)) { ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, webhook_outbound, or platform_service."); } if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty)) { ModelState.AddModelError(nameof(model.TenantId), "Tenant is required for this usage."); } if (RequiresClientCredentials(model.Usage) && !string.Equals(model.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)) { ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage."); } if (!ModelState.IsValid) { model.Tenants = await _tenantService.ListAsync(); return View(model); } var clientId = Guid.NewGuid().ToString("N"); var clientSecret = model.ClientType == "confidential" ? Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) : null; var descriptor = BuildDescriptor(clientId, model.Name, model.ClientType, model.Usage); if (!string.IsNullOrWhiteSpace(clientSecret)) { descriptor.ClientSecret = clientSecret; } foreach (var uri in model.RedirectUris.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { descriptor.RedirectUris.Add(new Uri(uri)); } if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue) { descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString()); } descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage); await _applicationManager.CreateAsync(descriptor); TempData["CreatedClientId"] = clientId; if (!string.IsNullOrWhiteSpace(clientSecret)) { TempData["CreatedClientSecret"] = clientSecret; } return RedirectToAction("Index"); } [HttpGet("edit/{id}")] public async Task Edit(string id) { var app = await _applicationManager.FindByIdAsync(id); if (app is null) { return NotFound(); } var redirectUris = await _applicationManager.GetRedirectUrisAsync(app); var properties = await _applicationManager.GetPropertiesAsync(app); var tenantId = properties.TryGetValue("tenant_id", out var value) ? value.GetString() : string.Empty; var usage = properties.TryGetValue("usage", out var usageValue) ? usageValue.GetString() : "tenant_api"; var tenants = await _tenantService.ListAsync(); return View(new OAuthClientFormViewModel { TenantId = Guid.TryParse(tenantId, out var parsed) ? parsed : null, Name = await _applicationManager.GetDisplayNameAsync(app) ?? string.Empty, ClientType = await _applicationManager.GetClientTypeAsync(app) ?? "public", Usage = string.IsNullOrWhiteSpace(usage) ? "tenant_api" : usage, RedirectUris = string.Join(",", redirectUris.Select(u => u.ToString())), Tenants = tenants }); } [HttpPost("edit/{id}")] public async Task Edit(string id, OAuthClientFormViewModel model) { if (!IsValidUsage(model.Usage)) { ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, webhook_outbound, or platform_service."); } if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty)) { ModelState.AddModelError(nameof(model.TenantId), "Tenant is required for this usage."); } if (RequiresClientCredentials(model.Usage) && !string.Equals(model.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)) { ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage."); } if (!ModelState.IsValid) { model.Tenants = await _tenantService.ListAsync(); return View(model); } var app = await _applicationManager.FindByIdAsync(id); if (app is null) { return NotFound(); } var descriptor = new OpenIddictApplicationDescriptor(); await _applicationManager.PopulateAsync(descriptor, app); descriptor.DisplayName = model.Name; descriptor.ClientType = model.ClientType; ApplyPermissions(descriptor, model.Usage); descriptor.RedirectUris.Clear(); foreach (var uri in model.RedirectUris.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { descriptor.RedirectUris.Add(new Uri(uri)); } if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue) { descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString()); } else { descriptor.Properties.Remove("tenant_id"); } descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage); await _applicationManager.UpdateAsync(app, descriptor); return RedirectToAction("Index"); } [HttpPost("delete/{id}")] public async Task Delete(string id) { var app = await _applicationManager.FindByIdAsync(id); if (app is null) { return NotFound(); } await _applicationManager.DeleteAsync(app); return RedirectToAction("Index"); } [HttpPost("rotate-secret/{id}")] public async Task RotateSecret(string id) { var app = await _applicationManager.FindByIdAsync(id); if (app is null) { return NotFound(); } var clientType = await _applicationManager.GetClientTypeAsync(app); if (!string.Equals(clientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)) { return BadRequest("Only confidential clients have secrets."); } var descriptor = new OpenIddictApplicationDescriptor(); await _applicationManager.PopulateAsync(descriptor, app); var newSecret = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); descriptor.ClientSecret = newSecret; await _applicationManager.UpdateAsync(app, descriptor); TempData["RotatedClientId"] = await _applicationManager.GetClientIdAsync(app); TempData["RotatedClientSecret"] = newSecret; return RedirectToAction("Index"); } private static bool IsValidUsage(string usage) { return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase) || string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase) || string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase); } private static bool IsTenantOptionalUsage(string usage) { return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase); } private static bool RequiresClientCredentials(string usage) { return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase) || string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase); } private static OpenIddictApplicationDescriptor BuildDescriptor(string clientId, string name, string clientType, string usage) { var descriptor = new OpenIddictApplicationDescriptor { ClientId = clientId, DisplayName = name, ClientType = clientType }; ApplyPermissions(descriptor, usage); return descriptor; } private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage) { descriptor.Permissions.Clear(); descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token); if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase) || string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)) { descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials); if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)) { descriptor.Permissions.Add("scp:newsletter:events.write.global"); } else { descriptor.Permissions.Add("scp:newsletter:events.write"); } return; } 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); descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Email); descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile); descriptor.Permissions.Add("scp:openid"); } }