using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; // ← SWAP: remove for AspNetCore using Microsoft.Extensions.Logging; using Registration.Data; using System.Security.Claims; using System.Text.Json; namespace Registration.Functions; /// /// Registration HTTP endpoints. /// /// ENDPOINTS: /// POST /api/registration/register — Public; requires Bearer token (CIAM JWT). /// entraSubjectId is extracted from the validated /// token — the client never supplies it. /// /// GET /api/registration/pending — Admin; requires internal API key. /// GET /api/registration/item/{id} — Admin; requires internal API key. /// POST /api/registration/action/{id}/reject — Admin; requires internal API key. /// POST /api/registration/action/{id}/complete — Admin; requires internal API key. /// GET /api/health — Anonymous. /// /// ═══════════════════════════════════════════════════════ /// HOST SWAP — three files change, everything else stays: /// 1. Functions/RegistrationFunctions.cs ← this file /// 2. Program.cs /// 3. Registration.csproj /// /// To switch to ASP.NET Core: /// a) In this file: swap class declaration and method signatures (marked below) /// b) In Program.cs: comment Azure Functions block, uncomment AspNetCore block /// c) In Registration.csproj: swap ItemGroup (marked in that file) /// d) In authConfig.js (client): update API_BASE_URL, remove API_FUNCTION_KEY /// ═══════════════════════════════════════════════════════ /// // ── SWAP: Azure Functions class declaration ─────────────────────────────── public class RegistrationFunctions // ── SWAP: AspNetCore class declaration ──────────────────────────────────── // [ApiController] // [Route("api")] // public class RegistrationFunctions : ControllerBase // ───────────────────────────────────────────────────────────────────────── { private readonly IRegistrationDataService _data; private readonly ILogger _log; private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; public RegistrationFunctions(IRegistrationDataService data, ILogger log) { _data = data; _log = log; } // ── Helpers ────────────────────────────────────────────────────────── /// /// Extract the Entra Object ID from a validated CIAM JWT. /// The OID claim is stable across sessions and providers (Google, Apple, Microsoft). /// private static string? GetEntraSubjectId(ClaimsPrincipal user) => user.FindFirst("oid")?.Value ?? user.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value ?? user.FindFirst("sub")?.Value; // ── Public: Register ───────────────────────────────────────────────── // ── SWAP: Azure Functions ───────────────────────────────────────────── [Function("Register")] [Authorize] public async Task Register( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/register")] HttpRequest req, CancellationToken ct) // ── SWAP: AspNetCore ────────────────────────────────────────────────── // [HttpPost("registration/register")] // [Authorize] // public async Task Register(CancellationToken ct) // ───────────────────────────────────────────────────────────────────── { // AspNetCore only — uncomment when in AspNetCore mode: // var req = HttpContext.Request; var entraSubjectId = GetEntraSubjectId(req.HttpContext.User); if (string.IsNullOrEmpty(entraSubjectId)) { _log.LogWarning("[Registration] Register called without a valid token OID claim"); return new UnauthorizedObjectResult(new { ok = false, error = "Valid authentication required" }); } RegisterRequest? request; try { request = await JsonSerializer.DeserializeAsync(req.Body, JsonOpts, ct); } catch { return new BadRequestObjectResult(new { ok = false, error = "Invalid JSON body" }); } if (request == null || string.IsNullOrWhiteSpace(request.BusinessName)) return new BadRequestObjectResult(new { ok = false, error = "businessName is required" }); request.EntraSubjectId = entraSubjectId; _log.LogInformation("[Registration] POST register: {Name} by entra={EntraId}", request.BusinessName, entraSubjectId); var result = await _data.RegisterAsync(request, ct); if (!result.Ok) return new BadRequestObjectResult(result); return new OkObjectResult(result); } // ── Admin: List pending ─────────────────────────────────────────────── // ── SWAP: Azure Functions ───────────────────────────────────────────── [Function("GetPending")] public async Task GetPending( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "registration/pending")] HttpRequest req, CancellationToken ct) // ── SWAP: AspNetCore ────────────────────────────────────────────────── // [HttpGet("registration/pending")] // public async Task GetPending(CancellationToken ct) // ───────────────────────────────────────────────────────────────────── { _log.LogInformation("[Registration] GET pending"); var result = await _data.GetPendingAsync(ct); return new OkObjectResult(result); } // ── Admin: Get by ID ───────────────────────────────────────────────── // ── SWAP: Azure Functions ───────────────────────────────────────────── [Function("GetById")] public async Task GetById( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "registration/item/{registrationId}")] HttpRequest req, string registrationId, CancellationToken ct) // ── SWAP: AspNetCore ────────────────────────────────────────────────── // [HttpGet("registration/item/{registrationId}")] // public async Task GetById(string registrationId, CancellationToken ct) // ───────────────────────────────────────────────────────────────────── { _log.LogInformation("[Registration] GET {Id}", registrationId); var applicant = await _data.GetByIdAsync(registrationId, ct); if (applicant == null) return new NotFoundObjectResult(new { ok = false, error = "Registration not found" }); return new OkObjectResult(new { ok = true, applicant }); } // ── Admin: Reject ──────────────────────────────────────────────────── // ── SWAP: Azure Functions ───────────────────────────────────────────── [Function("Reject")] public async Task Reject( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/action/{registrationId}/reject")] HttpRequest req, string registrationId, CancellationToken ct) // ── SWAP: AspNetCore ────────────────────────────────────────────────── // [HttpPost("registration/action/{registrationId}/reject")] // public async Task Reject(string registrationId, CancellationToken ct) // ───────────────────────────────────────────────────────────────────── { // AspNetCore only — uncomment when in AspNetCore mode: // var req = HttpContext.Request; RejectBody? body = null; try { body = await JsonSerializer.DeserializeAsync(req.Body, JsonOpts, ct); } catch { /* optional body */ } _log.LogInformation("[Registration] POST reject: {Id} reason={Reason}", registrationId, body?.Reason); var result = await _data.RejectAsync(registrationId, body?.Reason, body?.RejectedBy, ct); if (!result.Ok) return new BadRequestObjectResult(result); return new OkObjectResult(result); } // ── Admin: Complete ─────────────────────────────────────────────────── // ── SWAP: Azure Functions ───────────────────────────────────────────── [Function("Complete")] public async Task Complete( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/action/{registrationId}/complete")] HttpRequest req, string registrationId, CancellationToken ct) // ── SWAP: AspNetCore ────────────────────────────────────────────────── // [HttpPost("registration/action/{registrationId}/complete")] // public async Task Complete(string registrationId, CancellationToken ct) // ───────────────────────────────────────────────────────────────────── { // AspNetCore only — uncomment when in AspNetCore mode: // var req = HttpContext.Request; CompleteBody? body = null; try { body = await JsonSerializer.DeserializeAsync(req.Body, JsonOpts, ct); } catch { /* optional body */ } _log.LogInformation("[Registration] POST complete: {Id} → platform client {PlatformId}", registrationId, body?.PlatformClientId); var result = await _data.CompleteAsync(registrationId, body?.PlatformClientId, ct); if (!result.Ok) return new BadRequestObjectResult(result); return new OkObjectResult(result); } // ── Health ─────────────────────────────────────────────────────────── // ── SWAP: Azure Functions ───────────────────────────────────────────── [Function("Health")] public IActionResult Health( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health")] HttpRequest req) // ── SWAP: AspNetCore ────────────────────────────────────────────────── // [HttpGet("health")] // public IActionResult Health() // ───────────────────────────────────────────────────────────────────── { return new OkObjectResult(new { ok = true, service = "registration", mode = _data is Registration.Mock.MockDataService ? "mock" : "database", timestamp = DateTime.UtcNow }); } } // ── Request / Response Bodies ───────────────────────────────────────────── internal sealed class RejectBody { public string? Reason { get; set; } public string? RejectedBy { get; set; } } internal sealed class CompleteBody { public string? PlatformClientId { get; set; } }