External Identity Federation — Raw Technical Reference
Status: Primary source material, preserved intact. Developed 2026-08-21 in a separate, limited-context conversation exploring OpenIddict specifics against a RoundTrip-shaped (FastEndpoints, Clean Architecture, no ASP.NET Core Identity) demo project. Synthesized and evaluated against full RoundTrip/platform context in traxs-identity-framing.md, Section 11 — read that first for the actual decision-relevant analysis. This document exists so the real code patterns survive intact for whenever the OpenIddict spike gets built, rather than being lost to a synthesized summary.
A caveat from the person who developed this, worth keeping attached: the chat that produced this had limited context about RoundTrip's full system — it knew the general shape (FastEndpoints, Clean Architecture, TenantUsers/Technician as separate tables) but not the full history of TRA-384/389, the guest-vs-member saga, or the rest of this project's Entra investigation. Evaluate accordingly.
1. Root cause
Entra External ID's Home Realm Discovery can silently redirect a user to their own company's Entra tenant based on their email domain, instead of showing RoundTrip's own login — regardless of framework or user-store choice. This is a property of Entra's global directory graph, not something any application-level code choice introduces or fixes. The fix: don't use Entra External ID's auto-routing at all — own the whole login path directly.
2. Architecture: linking, not coupling
Core principle: Microsoft's token proves who, your own data decides what they can access.
public class TenantUser
{
// ...
public List<ExternalLogin> ExternalLogins { get; private set; } = [];
}
public class ExternalLogin
{
public Guid Id { get; set; }
public string Provider { get; set; } = default!; // "MicrosoftEntra"
public string ProviderKey { get; set; } = default!; // Microsoft's stable oid
public string Email { get; set; } = default!;
public DateTime LinkedUtc { get; set; }
}
Technician gets its own parallel ExternalLogins collection, backed by its own table (TechnicianExternalLogins) — two collections, not one shared table, consistent with each being its own aggregate and avoiding cross-type ambiguity (see Section 4).
3. Implementation
3.1 Entra App Registration
Separate App Registration, multi-tenant ("Accounts in any organizational directory"), redirect URI to the identity service, openid/profile/email scopes only.
3.2 Program.cs registration
.AddCookie("ExternalCookie")
.AddOpenIdConnect("MicrosoftEntra", options =>
{
options.Authority = "https://login.microsoftonline.com/organizations/v2.0";
// ...ClientId/ClientSecret/CallbackPath/scopes...
options.TokenValidationParameters.ValidateIssuer = true;
options.TokenValidationParameters.IssuerValidator =
AadIssuerValidator.GetAadIssuerValidator(options.Authority).Validate;
options.SignInScheme = "ExternalCookie";
});
3.3 FastEndpoints login + linking
Two endpoints, scoped to TenantUser specifically — a technician-facing version would be a parallel pair of endpoints against Technicians, never a shared handler searching both tables:
public class TenantUserExternalLoginEndpoint : EndpointWithoutRequest
{
public override void Configure()
{
Get("/account/tenant-login/external/{provider}");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken ct)
{
var provider = Route<string>("provider"); // "MicrosoftEntra"
var returnUrl = Query<string>("returnUrl", isRequired: false) ?? "/";
await HttpContext.ChallengeAsync(provider, new AuthenticationProperties
{
RedirectUri = $"/account/tenant-login/external-callback?returnUrl={Uri.EscapeDataString(returnUrl)}"
});
}
}
public class TenantUserExternalLoginCallbackEndpoint(IdentityDbContext db) : EndpointWithoutRequest
{
public override void Configure()
{
Get("/account/tenant-login/external-callback");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken ct)
{
var returnUrl = Query<string>("returnUrl", isRequired: false) ?? "/";
var result = await HttpContext.AuthenticateAsync("ExternalCookie");
if (result is not { Succeeded: true })
{
await Send.RedirectAsync("/account/tenant-login", ct);
return;
}
var providerKey = result.Principal!.FindFirst(
"http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value;
var email = result.Principal.FindFirst("email")?.Value;
var emailVerified = result.Principal.FindFirst("email_verified")?.Value;
if (providerKey is null || email is null)
{
await Send.StringAsync("Microsoft didn't return the identity details we need.", cancellation: ct);
return;
}
// Case 1: already linked.
var linkedUser = await db.TenantUsers.Include(u => u.ExternalLogins)
.FirstOrDefaultAsync(u => u.ExternalLogins.Any(
el => el.Provider == "MicrosoftEntra" && el.ProviderKey == providerKey), ct);
if (linkedUser is not null)
{
// sign in with "IdentityAppCookie" using linkedUser.Id
await Send.RedirectAsync(returnUrl, ct);
return;
}
// Case 2: not linked, only proceed on a verified email.
if (emailVerified != "true")
{
await Send.StringAsync(
"We couldn't verify your email from Microsoft. Sign in with your " +
"password instead, then link your Microsoft account from Settings.",
cancellation: ct);
return;
}
var existingByEmail = await db.TenantUsers.FirstOrDefaultAsync(u => u.Email == email, ct);
if (existingByEmail is not null)
{
// Never auto-merge — redirect to a password-confirmation step
// before calling ExternalLogins.Add(...) + SaveChangesAsync.
await Send.RedirectAsync(
$"/account/confirm-link?email={Uri.EscapeDataString(email)}&providerKey={Uri.EscapeDataString(providerKey)}",
ct);
return;
}
// Case 3: brand-new person — still requires a pre-existing invite.
await Send.StringAsync("No pending invite found for this email.", cancellation: ct);
}
}
3.4 Admin visibility (unlink)
var user = await db.TenantUsers.Include(u => u.ExternalLogins)
.FirstAsync(u => u.Id == tenantUserId);
var link = user.ExternalLogins.FirstOrDefault(el => el.Provider == "MicrosoftEntra");
if (link is not null)
{
user.ExternalLogins.Remove(link);
await db.SaveChangesAsync();
}
4. The guardrail
Never auto-route based on email domain. The TenantUser/Technician split reinforces this discipline one level down — the callback endpoint only ever looks in one table, scoped by which URL the login started from, rather than searching across both by email. Same logic as "don't infer identity from a domain": don't infer which kind of user someone is from their email either — let the entry point decide.
Worth keeping in mind if a technician-facing login is ever built: if a technician and a tenant user ever legitimately share an email address, signing in via the tenant-login Microsoft flow should never resolve to the technician record, and vice versa — the two lookups should be structurally incapable of crossing over, not just correct by convention.
5. Testing checklist
- A technician and a tenant user sharing an email never cross-resolve during Microsoft sign-in, in either direction — structurally incapable, not just correct by convention.