Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions Controllers/AccountController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;

namespace IdentityBridge.Controllers
{
/// <summary>
/// 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.
/// </summary>
public class AccountController : Controller
{
private static readonly Dictionary<string, (string Scheme, Claim[] Claims)> 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<IActionResult> 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<Claim>(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<IActionResult> Logout(string idp)
{
if (idp is not null && Providers.TryGetValue(idp, out var provider))
await HttpContext.SignOutAsync(provider.Scheme);
return Redirect("/");
}
}
}
33 changes: 33 additions & 0 deletions Controllers/HomeController.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,49 @@
using IdentityBridge.Models;
using IdentityBridge.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;

namespace IdentityBridge.Controllers
{
/// <summary>
/// Controller for the home page and user profile. Uses the IUserProfileServiceFactory to obtain a provider-agnostic user profile.
/// </summary>
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();
}

/// <summary>
/// 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.
/// </summary>
/// <returns>A JSON representation of the user profile.</returns>
[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 });
}

/// <summary>
/// GET: /Home/Privacy
/// </summary>
/// <returns>The privacy view.</returns>
public IActionResult Privacy()
{
return View();
Expand Down
47 changes: 47 additions & 0 deletions Program.cs
Original file line number Diff line number Diff line change
@@ -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, IdentityBridgeClaimsTransformation>(); // 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<IUserProfileService, EntraUserProfileService>();
builder.Services.AddSingleton<IUserProfileService, Auth0UserProfileService>();
builder.Services.AddSingleton<IUserProfileService, CognitoUserProfileService>();
builder.Services.AddSingleton<IUserProfileServiceFactory, UserProfileServiceFactory>();

// 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.
Expand All @@ -16,6 +62,7 @@
app.UseHttpsRedirection();
app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapStaticAssets();
Expand Down
25 changes: 25 additions & 0 deletions Services/Auth0UserProfileService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Security.Claims;

namespace IdentityBridge.Services
{
/// <summary>
/// Adapter for Auth0. Maps Auth0's raw claim shape (sub = "auth0|xxx", name) onto the
/// normalized <see cref="IUserProfile"/>. A real fork would enrich from the Auth0
/// Management API here; MVP reads claims only — no HTTP round-trip.
/// </summary>
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);
}
}
}
25 changes: 25 additions & 0 deletions Services/CognitoUserProfileService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Security.Claims;

namespace IdentityBridge.Services
{
/// <summary>
/// Adapter for Amazon Cognito. Maps Cognito's raw claim shape (cognito:username,
/// cognito:groups) onto the normalized <see cref="IUserProfile"/>. A real fork would
/// enrich from the Cognito Identity API here; MVP reads claims only — no HTTP round-trip.
/// </summary>
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);
}
}
}
25 changes: 25 additions & 0 deletions Services/EntraUserProfileService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Security.Claims;

namespace IdentityBridge.Services
{
/// <summary>
/// Adapter for Azure Entra ID. Maps Entra's raw claim shape (oid / preferred_username)
/// onto the normalized <see cref="IUserProfile"/>. A real fork would enrich from Graph
/// API here; MVP reads claims only — no HTTP round-trip.
/// </summary>
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);
}
}
}
27 changes: 27 additions & 0 deletions Services/IUserProfileService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Security.Claims;

namespace IdentityBridge.Services
{
/// <summary>Normalized identity contract every provider adapter maps onto.</summary>
public interface IUserProfile
{
string Id { get; }
string DisplayName { get; }
string Idp { get; }
}

/// <summary>
/// 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).
/// </summary>
public interface IUserProfileService
{
/// <summary>Normalized provider key this adapter serves: "entra" | "auth0" | "cognito".</summary>
string Idp { get; }

IUserProfile GetProfile(ClaimsPrincipal user);
}

public sealed record UserProfile(string Id, string DisplayName, string Idp) : IUserProfile;
}
39 changes: 39 additions & 0 deletions Services/IdentityBridgeClaimsTransformation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;

namespace IdentityBridge.Services
{
/// <summary>
/// Backfills the normalized <c>idp</c> 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.
/// </summary>
public sealed class IdentityBridgeClaimsTransformation : IClaimsTransformation
{
public Task<ClaimsPrincipal> 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;
}
}
}
47 changes: 47 additions & 0 deletions Services/UserProfileServiceFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Security.Claims;

namespace IdentityBridge.Services
{
/// <summary>Simple Factory: resolves the adapter matching the normalized <c>idp</c> claim.</summary>
public interface IUserProfileServiceFactory
{
/// <summary>
/// Resolves the adapter matching the normalized <c>idp</c> claim. Throws if no match is found.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the authenticated user.</param>
/// <returns>The <see cref="IUserProfileService"/> corresponding to the user's identity provider.</returns>
/// <exception cref="InvalidOperationException">Thrown if no <c>idp</c> claim is present or no matching adapter is registered.</exception>
IUserProfileService Resolve(ClaimsPrincipal user);
}

/// <summary>
/// Inspects the normalized <c>idp</c> 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).
/// </summary>
public sealed class UserProfileServiceFactory : IUserProfileServiceFactory
{
private readonly IReadOnlyDictionary<string, IUserProfileService> _byIdp;

public UserProfileServiceFactory(IEnumerable<IUserProfileService> services)
{
_byIdp = services.ToDictionary(s => s.Idp, StringComparer.OrdinalIgnoreCase);
}

/// <summary>
/// Resolves the adapter matching the normalized <c>idp</c> claim. Throws if no match is found.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the authenticated user.</param>
/// <returns>The <see cref="IUserProfileService"/> corresponding to the user's identity provider.</returns>
/// <exception cref="InvalidOperationException">Thrown if no <c>idp</c> claim is present or no matching adapter is registered.</exception>
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;
}
}
}