diff --git a/src/dotnet/Modgud.Api.Tests/Security/SamlAcsReplayEndToEndTests.cs b/src/dotnet/Modgud.Api.Tests/Security/SamlAcsReplayEndToEndTests.cs
new file mode 100644
index 00000000..114e0702
--- /dev/null
+++ b/src/dotnet/Modgud.Api.Tests/Security/SamlAcsReplayEndToEndTests.cs
@@ -0,0 +1,263 @@
+using System.Net;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Xml.Linq;
+using ErrorOr;
+using ITfoxtec.Identity.Saml2;
+using ITfoxtec.Identity.Saml2.Schemas;
+using Marten;
+using Microsoft.Extensions.DependencyInjection;
+using Modgud.Api.Tests.Infrastructure;
+using Modgud.Authentication.Api.Admin.LoginProviders.Commands;
+using Modgud.Authentication.Api.ExternalAuth.Saml;
+using Modgud.Authentication.Domain.LoginProviders;
+using Modgud.Authentication.Domain.Saml;
+using Modgud.Infrastructure.Persistence.Tenancy;
+using Wolverine;
+
+namespace Modgud.Api.Tests.Security;
+
+///
+/// End-to-end SAML replay protection through the REAL HTTP endpoints (P0-2).
+///
+///
+/// pins the store in isolation. This one
+/// closes the gap that class's doc called out: it drives GET /saml/{slug}/login
+/// and POST /saml/{slug}/acs over HTTP, with a self-hosted test IdP that
+/// mints a genuinely signed SAML Response. That is what proves the two things the
+/// store-level test cannot: that the AuthnRequest ID the flow persists is the exact
+/// ID that rides InResponseTo on the way back (a mismatch would break every
+/// SAML login, not just replays), and that the ACS actually consumes it.
+///
+///
+///
+/// The IdP side is ITfoxtec itself, configured with a throwaway signing cert whose
+/// public half is published in the metadata XML the SP validates against — the same
+/// library on both ends, so the signature is real, not stubbed.
+///
+///
+[Collection(IntegrationTestCollection.Name)]
+public class SamlAcsReplayEndToEndTests : IntegrationTestBase
+{
+ public SamlAcsReplayEndToEndTests(SharedPostgresFixture fixture) : base(fixture) { }
+
+ private const string Slug = "e2e-idp";
+ private const string IdpEntityId = "urn:test:e2e-idp";
+ private const string IdpSsoUrl = "https://idp.test/sso";
+ private const string SubjectNameId = "e2e-user@test.com";
+
+ // http://localhost is the WebApplicationFactory default base address, so
+ // RealmMiddleware resolves these requests to the seeded "system" realm and the
+ // SP entity id / ACS url match what SamlContextBuilder derives from the request.
+ private static string SpEntityId => $"http://localhost/saml/{Slug}/sp-metadata";
+ private static string AcsUrl => $"http://localhost/saml/{Slug}/acs";
+
+ [Fact]
+ public async Task Happy_path_consumes_the_request_and_replay_is_refused()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ using var idpCert = CreateIdpSigningCert();
+ await CreateAndRegisterProviderAsync(idpCert);
+
+ var http = Factory.CreateDefaultClient(); // does not auto-follow redirects
+
+ // 1. SP-initiated login. The flow persists the AuthnRequest it generates.
+ var login = await http.GetAsync($"/saml/{Slug}/login?returnUrl=%2F", ct);
+ Assert.Equal(HttpStatusCode.Redirect, login.StatusCode);
+ Assert.StartsWith(IdpSsoUrl, login.Headers.Location!.ToString());
+
+ // The persisted request is the source of the ID we must echo. Reading it
+ // (rather than parsing the deflated SAMLRequest) also asserts the flow
+ // recorded exactly one pending request.
+ var requestId = await SinglePendingRequestIdAsync(ct);
+
+ // 2. The IdP mints a signed Response answering that exact request.
+ var samlResponse = MintSignedResponse(idpCert, requestId);
+
+ // 3. First presentation: must get PAST correlation and consume the request.
+ var first = await PostToAcsAsync(http, samlResponse, ct);
+ Assert.Equal(HttpStatusCode.Redirect, first.StatusCode);
+ AssertNotACorrelationRejection(first.Headers.Location!.ToString());
+
+ var consumed = await LoadPendingRequestAsync(requestId, ct);
+ Assert.NotNull(consumed);
+ Assert.True(consumed!.IsConsumed,
+ "the ACS must consume the pending request when InResponseTo matches");
+
+ // 4. Replay the very same captured Response. Now the request is spent.
+ var replay = await PostToAcsAsync(http, samlResponse, ct);
+ Assert.Equal(HttpStatusCode.Redirect, replay.StatusCode);
+ Assert.Contains("error=saml-replayed", replay.Headers.Location!.ToString());
+ }
+
+ [Fact]
+ public async Task A_signed_response_with_an_unknown_InResponseTo_is_refused()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ using var idpCert = CreateIdpSigningCert();
+ await CreateAndRegisterProviderAsync(idpCert);
+
+ var http = Factory.CreateDefaultClient();
+
+ // A correctly-signed Response — but referencing a request this SP never
+ // sent. It must clear signature validation and then be refused at
+ // correlation, which is what proves the two checks are both present and
+ // correctly ordered (signature first, correlation second).
+ var samlResponse = MintSignedResponse(idpCert, inResponseTo: $"_id{Guid.NewGuid():N}");
+
+ var result = await PostToAcsAsync(http, samlResponse, ct);
+ Assert.Equal(HttpStatusCode.Redirect, result.StatusCode);
+ Assert.Contains("error=saml-unknown-request", result.Headers.Location!.ToString());
+ }
+
+ // ── arrange helpers ──────────────────────────────────────────────────────
+
+ private async Task CreateAndRegisterProviderAsync(X509Certificate2 idpCert)
+ {
+ var flavorData = JsonDocument.Parse(JsonSerializer.Serialize(new
+ {
+ metadataXml = BuildIdpMetadataXml(idpCert),
+ }));
+
+ using var scope = Factory.Services.CreateScope();
+ var bus = GetTenantedMessageBus(scope);
+ var result = await bus.InvokeAsync>(new CreateLoginProviderCommand(
+ Flavor: LoginProviderFlavor.GenericSaml,
+ DisplayName: "E2E SAML",
+ Slug: Slug,
+ FlavorData: flavorData,
+ Type: LoginProviderType.Saml,
+ Enabled: true));
+
+ Assert.False(result.IsError, result.IsError ? result.FirstError.Description : "");
+
+ // The Wolverine event cascade that normally registers the provider runs on
+ // a background pump with no drain hook in tests, so register explicitly —
+ // the same pattern DynamicOidcSchemeManagerTests uses.
+ var manager = scope.ServiceProvider.GetRequiredService();
+ using (TenantContext.Enter("system"))
+ await manager.RegisterAsync(result.Value);
+ }
+
+ private async Task SinglePendingRequestIdAsync(CancellationToken ct)
+ {
+ await using var qs = GetTenantedSession();
+ var pending = await qs.Query().ToListAsync(ct);
+ return Assert.Single(pending).Id;
+ }
+
+ private async Task LoadPendingRequestAsync(string id, CancellationToken ct)
+ {
+ await using var qs = GetTenantedSession();
+ return await qs.LoadAsync(id, ct);
+ }
+
+ private static async Task PostToAcsAsync(
+ HttpClient http, string samlResponseBase64, CancellationToken ct)
+ {
+ var form = new FormUrlEncodedContent(new[]
+ {
+ new KeyValuePair("SAMLResponse", samlResponseBase64),
+ new KeyValuePair("RelayState", "/"),
+ });
+ return await http.PostAsync($"/saml/{Slug}/acs", form, ct);
+ }
+
+ private static void AssertNotACorrelationRejection(string location)
+ {
+ foreach (var code in new[]
+ {
+ "saml-unsolicited", "saml-replayed", "saml-expired",
+ "saml-unknown-request", "saml-provider-mismatch",
+ })
+ {
+ Assert.DoesNotContain(code, location);
+ }
+ }
+
+ // ── test IdP ─────────────────────────────────────────────────────────────
+
+ private static X509Certificate2 CreateIdpSigningCert()
+ {
+ using var rsa = RSA.Create(2048);
+ var req = new CertificateRequest(
+ "CN=modgud-e2e-test-idp", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ var now = DateTimeOffset.UtcNow;
+ var cert = req.CreateSelfSigned(now.AddMinutes(-5), now.AddYears(1));
+ // Round-trip through PKCS#12 so the returned cert keeps an exportable
+ // private key usable for XML signing.
+ return X509CertificateLoader.LoadPkcs12(
+ cert.Export(X509ContentType.Pkcs12), password: null, X509KeyStorageFlags.Exportable);
+ }
+
+ private static string BuildIdpMetadataXml(X509Certificate2 idpCert)
+ {
+ XNamespace md = "urn:oasis:names:tc:SAML:2.0:metadata";
+ XNamespace ds = "http://www.w3.org/2000/09/xmldsig#";
+ var certB64 = Convert.ToBase64String(idpCert.Export(X509ContentType.Cert));
+
+ var doc = new XDocument(
+ new XElement(md + "EntityDescriptor",
+ new XAttribute("entityID", IdpEntityId),
+ new XAttribute(XNamespace.Xmlns + "md", md.NamespaceName),
+ new XAttribute(XNamespace.Xmlns + "ds", ds.NamespaceName),
+ new XElement(md + "IDPSSODescriptor",
+ new XAttribute("protocolSupportEnumeration", "urn:oasis:names:tc:SAML:2.0:protocol"),
+ new XElement(md + "KeyDescriptor",
+ new XAttribute("use", "signing"),
+ new XElement(ds + "KeyInfo",
+ new XElement(ds + "X509Data",
+ new XElement(ds + "X509Certificate", certB64)))),
+ new XElement(md + "SingleSignOnService",
+ new XAttribute("Binding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"),
+ new XAttribute("Location", IdpSsoUrl)))));
+
+ return doc.ToString();
+ }
+
+ private static string MintSignedResponse(X509Certificate2 idpCert, string inResponseTo)
+ {
+ var idpConfig = new Saml2Configuration
+ {
+ Issuer = IdpEntityId,
+ SigningCertificate = idpCert,
+ // The SP's default floor is WantAssertionsSigned=true, which requires a
+ // per-assertion ds:Signature. ITfoxtec signs the Response wrapper by
+ // default, so ask it to sign the assertion instead.
+ AuthnResponseSignType = Saml2AuthnResponseSignTypes.SignAssertion,
+ };
+
+ var response = new Saml2AuthnResponse(idpConfig)
+ {
+ InResponseToAsString = inResponseTo,
+ Status = Saml2StatusCodes.Success,
+ Destination = new Uri(AcsUrl),
+ NameId = new Microsoft.IdentityModel.Tokens.Saml2.Saml2NameIdentifier(SubjectNameId),
+ };
+ response.ClaimsIdentity = new ClaimsIdentity(new[]
+ {
+ new Claim(ClaimTypes.NameIdentifier, SubjectNameId),
+ new Claim("email", SubjectNameId),
+ });
+
+ // appliesToAddress = the SP entity id => assertion audience. ITfoxtec signs
+ // the assertion during Bind using the config's SigningCertificate, which is
+ // what the SP's WantAssertionsSigned floor (default on) requires.
+ response.CreateSecurityToken(SpEntityId);
+
+ var binding = new Saml2PostBinding { RelayState = "/" };
+ binding.Bind(response);
+ return ExtractSamlResponseField(binding.PostContent);
+ }
+
+ private static string ExtractSamlResponseField(string postContent)
+ {
+ var match = Regex.Match(postContent,
+ "name=\"SAMLResponse\"[^>]*value=\"([^\"]*)\"", RegexOptions.IgnoreCase);
+ Assert.True(match.Success, "could not find SAMLResponse field in the bound POST content");
+ return System.Net.WebUtility.HtmlDecode(match.Groups[1].Value);
+ }
+}