After implementing "Configure IdP-Initiated SAML Sign-on to OIDC Apps", user has to SSO twice when IdP-initiated

I have followed the steps from Configure IdP-Initiated SAML Sign-on to OIDC Apps

With the following settings:

  • At Google Workspace

    • ACS: https://tenant.eu.auth0.com/login/callback?connection=xyz-google&organization=org_XYZ
    • Start URL (a.k.a RelayState): https://xyz.com
    • Entity ID: urn:auth0:tenant:connection
    • Name ID format: UNSPECIFIED
  • At Auth0 (as SAML Enterprise connection)

    • Sign in URL: https://accounts.google.com/o/saml2/idp?idpid=xyz
    • Certificate uploaded
    • User ID Attribute: https://accounts.google.com/o/saml2?idpid=xyz
    • Debug mode: ON
    • Accepting unsolicited login requests
    • Response protocol: OpenID Connect
    • Query string set with redirect_uri containing the custom login endpoint with the SAML connection name
    • HRD is set to the top domain used by Google Workspace
    • The application itself activated under the SAML connection settings and has the custom login endpoint as Allowed Callback URL

In Auth0, I have also updated the request template:

<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
    AssertionConsumerServiceUrl="@@AssertionConsumerServiceUrl@@"
    Destination="@@Destination@@"
    ID="@@ID@@"
    ProviderName="@@ProviderName@@"
    LoginHint="@@LoginHint@@"
    IssueInstant="@@IssueInstant@@"
    ProtocolBinding="@@ProtocolBinding@@" Version="2.0">
    <saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">@@Issuer@@</saml:Issuer>
</samlp:AuthnRequest>

In DotNet I have the following two endpoints:

		[HttpGet]
		public async Task Login(
			[FromQuery] string return_to = "/",
			[FromQuery] string? connection = null,
			[FromQuery] string? organization = null,
			[FromQuery] string? invitation = null,
			[FromQuery] string? error = null,
			[FromQuery] string? error_description = null,
			[FromQuery] string? state = null
		) {
			var builder = new LoginAuthenticationPropertiesBuilder().WithRedirectUri(return_to);

			if (!String.IsNullOrEmpty(connection)) builder.WithParameter("connection", connection);
			if (!String.IsNullOrEmpty(organization)) builder.WithOrganization(organization);
			if (!String.IsNullOrEmpty(invitation)) builder.WithInvitation(invitation);

			await HttpContext.ChallengeAsync(Auth0Constants.AuthenticationScheme, builder.Build());
		}

		[HttpGet]
		public Task LoginSAML(
			[FromQuery] string connection,
			[FromQuery] string? error = null,
			[FromQuery] string? error_description = null,
			[FromQuery] string? state = null
		) => Login(connection: connection, error: error, error_description: error_description, state: state);

And in Startup.cs:

	.AddAuthentication(opt => {
		opt.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
		opt.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
		opt.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
	})
	.AddCookie(opt => {
		opt.SlidingExpiration = true;
		opt.LoginPath = "/login";
		opt.LogoutPath = "/logout";
		opt.ReturnUrlParameter = "return_to";
		opt.Cookie.Name = ".auth";
		opt.Cookie.Domain = isDev ? "localhost" : "xyz.com";
		opt.Cookie.HttpOnly = true;
		opt.Cookie.SameSite = SameSiteMode.None;
		opt.Cookie.SecurePolicy = CookieSecurePolicy.Always;
		opt.Cookie.IsEssential = true;
	})
	.AddOpenIdConnect(Auth0Constants.AuthenticationScheme, opt => {
		opt.SaveTokens = true;
		opt.UseTokenLifetime = true;
		opt.Authority = builder.Configuration["Auth0:Domain"];
		opt.ClientId = builder.Configuration["Auth0:ClientId"];
		opt.ClientSecret = builder.Configuration["Auth0:ClientSecret"];
		opt.ResponseType = OpenIdConnectResponseType.Code;
		opt.CallbackPath = new PathString("/auth/callback");
		opt.ClaimsIssuer = Auth0Constants.AuthenticationScheme;

		opt.Scope.Clear();
		opt.Scope.Add("openid");

		opt.Events = new OpenIdConnectEvents {
			OnRedirectToIdentityProvider = ctx => {
				var connection = String.Empty;
				var organization = String.Empty;
				var invitation = String.Empty;

				if (ctx.Properties.Items.TryGetValue(
					Auth0AuthenticationParameters.Parameter("connection"),
					out connection
				)) ctx.ProtocolMessage.Parameters.Add(
					"connection",
					connection
				);

				if (ctx.Properties.Items.TryGetValue(
					Auth0AuthenticationParameters.Organization,
					out organization
				)) ctx.ProtocolMessage.Parameters.Add(
					"organization",
					organization
				);

				if (ctx.Properties.Items.TryGetValue(
					Auth0AuthenticationParameters.Invitation,
					out invitation
				)) ctx.ProtocolMessage.Parameters.Add(
					"invitation",
					invitation
				);

				return Task.CompletedTask;
			}
		};
	})

I think I have followed the documentation to a tee.

With all this, when the user performs an IdP-initiated login, the user has to login twice to Google Workspace. Is this correct or am I doing something wrong? Am I doing something wrong in DotNet with custom SAML login endpoint perhaps?

Here are the different endpoints being called:

  1. Googles SSO page
  2. User logs in
  3. Redirects to https://tenant.eu.auth0.com/login/callback?connection=xyz-google&organization=org_XYZ with form data SAMLResponse and RelayState=https://xyz.com
  4. Redirects to https://tenant.eu.auth0.com/authorize/resume?state=XYZ
  5. Redirects to https://xyz.com/auth/login/saml?connection=xyz-google&code=XYZ&state=https%3A%2F%2Fxyz.com ← this is the custom login endpoint
  6. Redirects to https://tenant.eu.auth0.com/authorize?client_id=XYZ&redirect_uri=https%3A%2F%2Fxyz.com%2Fauth%2Fcallback&response_type=code&scope=openid&code_challenge=XYZ&code_challenge_method=S256&response_mode=form_post&nonce=XYZ&connection=xyz-google&state=XYZ&x-client-SKU=ID_NET6_0&x-client-ver=6.25.1.0
  7. Redirects back to Googles SSO page
  8. Redirects again to https://tenant.eu.auth0.com/login/callback?connection=xyz-google&organization=org_XYZ with form data SAMLResponse and RelayState but this time RelayState is some hashed string
  9. Redirects again to https://tenant.eu.auth0.com/authorize/resume?state=123 with different state query string
  10. Redirect to https://xyz.com/auth/callback with form data code and state
  11. Redirect to application, logged in