diff --git a/Controllers/AccountController.cs b/Controllers/AccountController.cs new file mode 100644 index 0000000..7cd6b6e --- /dev/null +++ b/Controllers/AccountController.cs @@ -0,0 +1,61 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; + +namespace IdentityBridge.Controllers +{ + /// + /// Dev-only login simulator. Injects provider-shaped RAW claims (all values fake) into the + /// matching per-provider cookie scheme so the Adapter/Factory/Strategy trio can be exercised + /// end-to-end without any real IdP round-trip. + /// + public class AccountController : Controller + { + private static readonly Dictionary Providers = new() + { + ["entra"] = ("EntraCookie", new[] + { + new Claim("oid", "00000000-0000-0000-0000-0000000000e1"), + new Claim("preferred_username", "alice@contoso.onmicrosoft.com"), + }), + ["auth0"] = ("Auth0Cookie", new[] + { + new Claim("sub", "auth0|000000000000000000000001"), + new Claim("name", "Bob (Auth0)"), + }), + ["cognito"] = ("CognitoCookie", new[] + { + new Claim("sub", "00000000-0000-0000-0000-0000000000c0"), + new Claim("cognito:username", "carol.cognito"), + }), + }; + + // GET /Account/Simulate?idp=entra|auth0|cognito + [HttpGet] + public async Task Simulate(string idp, [FromServices] IWebHostEnvironment env) + { + if (!env.IsDevelopment()) + return NotFound(); + + if (idp is null || !Providers.TryGetValue(idp, out var provider)) + return BadRequest("idp must be one of: entra, auth0, cognito"); + + var claims = new List(provider.Claims) { new Claim("idp", idp) }; + var identity = new ClaimsIdentity(claims, provider.Scheme); + await HttpContext.SignInAsync(provider.Scheme, new ClaimsPrincipal(identity)); + + // Carry ?idp so the PolicyScheme selector routes the read to the same cookie scheme. + return Redirect($"/Home/Profile?idp={idp}"); + } + + // POST /Account/Logout?idp=... + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Logout(string idp) + { + if (idp is not null && Providers.TryGetValue(idp, out var provider)) + await HttpContext.SignOutAsync(provider.Scheme); + return Redirect("/"); + } + } +} diff --git a/Controllers/HomeController.cs b/Controllers/HomeController.cs index 0de6c11..d65eb4c 100644 --- a/Controllers/HomeController.cs +++ b/Controllers/HomeController.cs @@ -1,16 +1,49 @@ using IdentityBridge.Models; +using IdentityBridge.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Diagnostics; namespace IdentityBridge.Controllers { + /// + /// Controller for the home page and user profile. Uses the IUserProfileServiceFactory to obtain a provider-agnostic user profile. + /// public class HomeController : Controller { + // Deliverable: factory injected via DI, resolves the correct adapter based on the normalized idp claim. + private readonly IUserProfileServiceFactory _profileFactory; + + public HomeController(IUserProfileServiceFactory profileFactory) + { + _profileFactory = profileFactory; + } + public IActionResult Index() { return View(); } + /// + /// Returns the user profile in a provider-agnostic format. The profile is resolved using the IUserProfileServiceFactory, which selects the appropriate adapter based on the normalized idp claim. + /// + /// A JSON representation of the user profile. + [Authorize] + public IActionResult Profile() + { + // Deliverable: resolve the profile adapter based on the normalized idp claim, and return the profile in a provider-agnostic format. + //savety guard: if the idp claim is missing, return a clean 400 Bad Request with a message indicating that the profile adapter cannot be resolved, not a 500 Internal Server Error. otherwise, return the profile in a provider-agnostic format with the id, displayName, and idp properties. + if (User.FindFirst("idp")?.Value is not { Length: > 0 }) + return BadRequest("No 'idp' claim on principal; cannot resolve profile adapter."); + + var profile = _profileFactory.Resolve(User).GetProfile(User); + return Json(new { id = profile.Id, displayName = profile.DisplayName, idp = profile.Idp }); + } + + /// + /// GET: /Home/Privacy + /// + /// The privacy view. public IActionResult Privacy() { return View(); diff --git a/Program.cs b/Program.cs index 1510d12..7e619e2 100644 --- a/Program.cs +++ b/Program.cs @@ -1,8 +1,54 @@ +using IdentityBridge.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; + var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllersWithViews(); +// --- Strategy: one PolicyScheme dispatches each request to the right cookie scheme. +// It only selects (dispatches) — it never constructs an adapter. Selection here is by +// ?idp= query for the MVP simulate flow; a real fork keys off path/subdomain/session. +const string EntraCookie = "EntraCookie"; +const string Auth0Cookie = "Auth0Cookie"; +const string CognitoCookie = "CognitoCookie"; + +// Invariant: each provider gets its OWN dedicated cookie name — never shared. +builder.Services.AddAuthentication("IdentityBridge") + // The PolicyScheme is the "front door" for all requests. It dispatches to the right cookie scheme. + .AddPolicyScheme("IdentityBridge", "IdentityBridge", options => + { + // The selector runs on every request. It must be fast and side-effect-free. It only selects the scheme to use for this request; it never constructs an adapter or reads the cookie. + options.ForwardDefaultSelector = context => + { + var idp = context.Request.Query["idp"].ToString(); + return idp switch + { + "auth0" => Auth0Cookie, + "cognito" => CognitoCookie, + _ => EntraCookie + }; + }; + }) + // Invariant: each provider gets its OWN dedicated cookie name — never shared. + .AddCookie(EntraCookie, o => o.Cookie.Name = "ib.entra") + .AddCookie(Auth0Cookie, o => o.Cookie.Name = "ib.auth0") + .AddCookie(CognitoCookie, o => o.Cookie.Name = "ib.cognito"); + +builder.Services.AddAuthorization(); + +// Adapter + Factory wiring. Callers depend only on the abstractions. +builder.Services.AddTransient(); // IClaimsTransformation MS default guidance : always register as transient, never singleton.(run on every request). Stateless Singletons are safe here because they have no state, but transient is the recommended lifetime for IClaimsTransformation. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Multi-node note: cookies are Data-Protection-encrypted. Running on >1 node requires a +// shared key ring, else each node rejects the others' cookies. Wire per project, e.g.: +// builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(@"\\share\keys")); + var app = builder.Build(); // Configure the HTTP request pipeline. @@ -16,6 +62,7 @@ app.UseHttpsRedirection(); app.UseRouting(); +app.UseAuthentication(); app.UseAuthorization(); app.MapStaticAssets(); diff --git a/Services/Auth0UserProfileService.cs b/Services/Auth0UserProfileService.cs new file mode 100644 index 0000000..ae6e449 --- /dev/null +++ b/Services/Auth0UserProfileService.cs @@ -0,0 +1,25 @@ +using System.Security.Claims; + +namespace IdentityBridge.Services +{ + /// + /// Adapter for Auth0. Maps Auth0's raw claim shape (sub = "auth0|xxx", name) onto the + /// normalized . A real fork would enrich from the Auth0 + /// Management API here; MVP reads claims only — no HTTP round-trip. + /// + public sealed class Auth0UserProfileService : IUserProfileService + { + public string Idp => "auth0"; + + public IUserProfile GetProfile(ClaimsPrincipal user) + { + var id = user.FindFirst("sub")?.Value + ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? ""; + var name = user.FindFirst("name")?.Value + ?? user.Identity?.Name + ?? ""; + return new UserProfile(id, name, Idp); + } + } +} diff --git a/Services/CognitoUserProfileService.cs b/Services/CognitoUserProfileService.cs new file mode 100644 index 0000000..965fb22 --- /dev/null +++ b/Services/CognitoUserProfileService.cs @@ -0,0 +1,25 @@ +using System.Security.Claims; + +namespace IdentityBridge.Services +{ + /// + /// Adapter for Amazon Cognito. Maps Cognito's raw claim shape (cognito:username, + /// cognito:groups) onto the normalized . A real fork would + /// enrich from the Cognito Identity API here; MVP reads claims only — no HTTP round-trip. + /// + public sealed class CognitoUserProfileService : IUserProfileService + { + public string Idp => "cognito"; + + public IUserProfile GetProfile(ClaimsPrincipal user) + { + var id = user.FindFirst("sub")?.Value + ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? ""; + var name = user.FindFirst("cognito:username")?.Value + ?? user.Identity?.Name + ?? ""; + return new UserProfile(id, name, Idp); + } + } +} diff --git a/Services/EntraUserProfileService.cs b/Services/EntraUserProfileService.cs new file mode 100644 index 0000000..e27e7cb --- /dev/null +++ b/Services/EntraUserProfileService.cs @@ -0,0 +1,25 @@ +using System.Security.Claims; + +namespace IdentityBridge.Services +{ + /// + /// Adapter for Azure Entra ID. Maps Entra's raw claim shape (oid / preferred_username) + /// onto the normalized . A real fork would enrich from Graph + /// API here; MVP reads claims only — no HTTP round-trip. + /// + public sealed class EntraUserProfileService : IUserProfileService + { + public string Idp => "entra"; + + public IUserProfile GetProfile(ClaimsPrincipal user) + { + var id = user.FindFirst("oid")?.Value + ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? ""; + var name = user.FindFirst("preferred_username")?.Value + ?? user.Identity?.Name + ?? ""; + return new UserProfile(id, name, Idp); + } + } +} diff --git a/Services/IUserProfileService.cs b/Services/IUserProfileService.cs new file mode 100644 index 0000000..1fed133 --- /dev/null +++ b/Services/IUserProfileService.cs @@ -0,0 +1,27 @@ +using System.Security.Claims; + +namespace IdentityBridge.Services +{ + /// Normalized identity contract every provider adapter maps onto. + public interface IUserProfile + { + string Id { get; } + string DisplayName { get; } + string Idp { get; } + } + + /// + /// Adapter contract: one per identity provider. Each implementation knows how to read + /// its own provider's raw claim shape and normalize it. Callers depend on this, never + /// on a concrete adapter (Dependency Inversion). + /// + public interface IUserProfileService + { + /// Normalized provider key this adapter serves: "entra" | "auth0" | "cognito". + string Idp { get; } + + IUserProfile GetProfile(ClaimsPrincipal user); + } + + public sealed record UserProfile(string Id, string DisplayName, string Idp) : IUserProfile; +} diff --git a/Services/IdentityBridgeClaimsTransformation.cs b/Services/IdentityBridgeClaimsTransformation.cs new file mode 100644 index 0000000..762f931 --- /dev/null +++ b/Services/IdentityBridgeClaimsTransformation.cs @@ -0,0 +1,39 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; + +namespace IdentityBridge.Services +{ + /// + /// Backfills the normalized idp claim from raw provider claims when it is absent, + /// so the factory can route regardless of which upstream signed the user in. Idempotent: + /// runs on every request but adds the claim at most once. + /// + public sealed class IdentityBridgeClaimsTransformation : IClaimsTransformation + { + public Task TransformAsync(ClaimsPrincipal principal) + { + if (principal.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated) + return Task.FromResult(principal); + + if (principal.HasClaim(c => c.Type == "idp")) + return Task.FromResult(principal); + + var idp = InferIdp(principal); + if (idp is not null) + identity.AddClaim(new Claim("idp", idp)); + + return Task.FromResult(principal); + } + + private static string? InferIdp(ClaimsPrincipal p) + { + if (p.HasClaim(c => c.Type == "oid") || p.HasClaim(c => c.Type == "preferred_username")) + return "entra"; + if (p.HasClaim(c => c.Type == "cognito:username")) + return "cognito"; + if (p.FindFirst("sub")?.Value?.StartsWith("auth0|", StringComparison.OrdinalIgnoreCase) == true) + return "auth0"; + return null; + } + } +} diff --git a/Services/UserProfileServiceFactory.cs b/Services/UserProfileServiceFactory.cs new file mode 100644 index 0000000..50c2fc3 --- /dev/null +++ b/Services/UserProfileServiceFactory.cs @@ -0,0 +1,47 @@ +using System.Security.Claims; + +namespace IdentityBridge.Services +{ + /// Simple Factory: resolves the adapter matching the normalized idp claim. + public interface IUserProfileServiceFactory + { + /// + /// Resolves the adapter matching the normalized idp claim. Throws if no match is found. + /// + /// The representing the authenticated user. + /// The corresponding to the user's identity provider. + /// Thrown if no idp claim is present or no matching adapter is registered. + IUserProfileService Resolve(ClaimsPrincipal user); + } + + /// + /// Inspects the normalized idp claim and returns the matching adapter. Adding a + /// provider = one new adapter registered in DI; this factory and all call sites stay + /// provider-agnostic (they depend only on the abstraction). + /// + public sealed class UserProfileServiceFactory : IUserProfileServiceFactory + { + private readonly IReadOnlyDictionary _byIdp; + + public UserProfileServiceFactory(IEnumerable services) + { + _byIdp = services.ToDictionary(s => s.Idp, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Resolves the adapter matching the normalized idp claim. Throws if no match is found. + /// + /// The representing the authenticated user. + /// The corresponding to the user's identity provider. + /// Thrown if no idp claim is present or no matching adapter is registered. + public IUserProfileService Resolve(ClaimsPrincipal user) + { + var idp = user.FindFirst("idp")?.Value; + if (string.IsNullOrEmpty(idp)) + throw new InvalidOperationException("No 'idp' claim present; cannot resolve profile adapter."); + if (!_byIdp.TryGetValue(idp, out var service)) + throw new InvalidOperationException($"No adapter registered for idp '{idp}'."); + return service; + } + } +}