// ── SWAP note ───────────────────────────────────────────────────────────── // Three files change when switching host modes. See Registration.csproj for // the full checklist. This file: swap the class declaration and each method // signature (marked below). Program.cs and Registration.csproj also change. // ───────────────────────────────────────────────────────────────────────── using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Registration.Auth; using Registration.Data; using System.Security.Claims; using System.Text.Json; // ── SWAP: Azure Functions — uncomment this using when restoring Functions mode // using Microsoft.Azure.Functions.Worker; 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; x-functions-key header required. /// GET /api/registration/item/{id} — Admin; x-functions-key header required. /// POST /api/registration/action/{id}/reject — Admin; x-functions-key header required. /// POST /api/registration/action/{id}/complete — Admin; x-functions-key header required. /// 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 back to Azure Functions: /// a) Swap class declaration (marked below) /// b) Swap each method signature (marked below) /// c) Uncomment [Function(...)] and [HttpTrigger(...)] attributes /// d) Re-add req parameter and remove [ApiKeyAuth] on admin endpoints /// (Functions mode uses AuthorizationLevel.Function instead) /// e) In authConfig.js: update API_BASE_URL to Function App URL, /// set API_FUNCTION_KEY from Azure Portal → App Keys → default /// ═══════════════════════════════════════════════════════ /// // ── SWAP: ASP.NET Core class declaration ◄ ACTIVE ─────────────────────── [ApiController] [Route("api")] public class RegistrationFunctions : ControllerBase // ── SWAP: Azure Functions class declaration ◄ INACTIVE — uncomment to restore // public class RegistrationFunctions // ───────────────────────────────────────────────────────────────────────── { 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). /// CIAM tenant: PositiveSpendClients.ciamlogin.com / cbf8b7d7-1e13-486d-b5b0-287ba79fdf0b /// 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: ASP.NET Core ◄ ACTIVE ───────────────────────────────────── [HttpPost("registration/register")] [Authorize] public async Task Register(CancellationToken ct) // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore // [Function("Register")] // [Authorize] // public async Task Register( // [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/register")] HttpRequest req, // CancellationToken ct) // ───────────────────────────────────────────────────────────────────── { // ── SWAP: ASP.NET Core uses HttpContext.Request ─────────────────── var req = HttpContext.Request; // ── SWAP: Azure Functions — req is passed as parameter, remove above line 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" }); // Stamp from the validated token — never trust the request body for this. 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: ASP.NET Core ◄ ACTIVE ───────────────────────────────────── [HttpGet("registration/pending")] [ApiKeyAuth] public async Task GetPending(CancellationToken ct) // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore // [Function("GetPending")] // public async Task GetPending( // [HttpTrigger(AuthorizationLevel.Function, "get", Route = "registration/pending")] HttpRequest req, // CancellationToken ct) // ───────────────────────────────────────────────────────────────────── { _log.LogInformation("[Registration] GET pending"); var result = await _data.GetPendingAsync(ct); return new OkObjectResult(result); } // ── Admin: Get by ID ───────────────────────────────────────────────── // ── SWAP: ASP.NET Core ◄ ACTIVE ───────────────────────────────────── [HttpGet("registration/item/{registrationId}")] [ApiKeyAuth] public async Task GetById(string registrationId, CancellationToken ct) // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore // [Function("GetById")] // public async Task GetById( // [HttpTrigger(AuthorizationLevel.Function, "get", Route = "registration/item/{registrationId}")] HttpRequest req, // 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: ASP.NET Core ◄ ACTIVE ───────────────────────────────────── [HttpPost("registration/action/{registrationId}/reject")] [ApiKeyAuth] public async Task Reject(string registrationId, CancellationToken ct) // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore // [Function("Reject")] // public async Task Reject( // [HttpTrigger(AuthorizationLevel.Function, "post", Route = "registration/action/{registrationId}/reject")] HttpRequest req, // string registrationId, // CancellationToken ct) // ───────────────────────────────────────────────────────────────────── { // ── SWAP: ASP.NET Core ──────────────────────────────────────────── var req = HttpContext.Request; // ── SWAP: Azure Functions — req is passed as parameter, remove above line 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: ASP.NET Core ◄ ACTIVE ───────────────────────────────────── [HttpPost("registration/action/{registrationId}/complete")] [ApiKeyAuth] public async Task Complete(string registrationId, CancellationToken ct) // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore // [Function("Complete")] // public async Task Complete( // [HttpTrigger(AuthorizationLevel.Function, "post", Route = "registration/action/{registrationId}/complete")] HttpRequest req, // string registrationId, // CancellationToken ct) // ───────────────────────────────────────────────────────────────────── { // ── SWAP: ASP.NET Core ──────────────────────────────────────────── var req = HttpContext.Request; // ── SWAP: Azure Functions — req is passed as parameter, remove above line 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: ASP.NET Core ◄ ACTIVE ───────────────────────────────────── [HttpGet("health")] [AllowAnonymous] public IActionResult Health() // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore // [Function("Health")] // public IActionResult Health( // [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health")] HttpRequest req) // ───────────────────────────────────────────────────────────────────── { 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; } }