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;
+ }
+ }
+}