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
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,35 @@ public async Task MissingSubject_Fails()
Assert.Equal("Idp.InvalidToken", result.ErrorCode);
}

[Fact]
public async Task DeactivatedUser_WithExternalLink_CannotSignIn()
{
// Regression: the admin recycle-bin deactivates a user (IsActive=false) but
// deliberately keeps their external identity links. External login must
// refuse a deactivated/deleted user, exactly like password/magic-link/
// passkey do — otherwise a binned user could re-authenticate via their IdP
// and bypass the bin.
var config = await CreateEnabledEntraConfig();
var user = await Factory.CreateTestUserWithIdentityAsync("Ban", "Ned", "BN", "banned@acme.com");
await LinkUserAsync(user.Id, config.Id, subject: "sub-banned-1");

using (var scope = Factory.Services.CreateScope())
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var appUser = await userManager.FindByIdAsync(user.Id.ToString());
appUser!.IsActive = false;
await userManager.UpdateAsync(appUser);
}

using var scope2 = Factory.Services.CreateScope();
var processor = scope2.ServiceProvider.GetRequiredService<ExternalLoginProcessor>();
var external = BuildExternalPrincipal(subject: "sub-banned-1", email: "banned@acme.com", name: "Ban Ned");
var result = await processor.ProcessAsync(external, config.Id, default);

Assert.False(result.Succeeded);
Assert.Equal("Idp.UserInactive", result.ErrorCode);
}

private async Task<LoginProvider> CreateEnabledEntraConfig(
bool autoCreate = false,
bool trustForEmailLink = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,20 @@ private async Task<ExternalLoginResult> Success(
IReadOnlyList<string> externalGroups,
CancellationToken ct)
{
// A deactivated or deleted user must never receive a fresh app cookie via
// federation. Password / magic-link / passkey login all gate this, and the
// admin recycle-bin relies on IsActive as its ONLY lockout — so a binned
// user holding an external IdP link could otherwise re-authenticate through
// that provider and bypass the bin. RevokeAllAccessAsync only kills EXISTING
// sessions/tokens; it does not stop a fresh login. JIT-created users are
// IsActive=true, so the JIT path passes this gate unaffected.
if (user.IsDeleted || !user.IsActive)
{
logger.LogWarning(
"Auth: External login rejected — user {UserId} is inactive or deleted", user.Id);
return ExternalLoginResult.Failed("Idp.UserInactive", "This account is not active.");
}

// Build the sign-in ClaimsPrincipal. It carries session mechanics — link
// id + issuer for logout routing, amr for TwoFactorFederated — PLUS, for a
// provider trusted for authorization, the federation v1 "session group"
Expand Down
Loading