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
- ACS:
-
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
- Sign in 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:
- Googles SSO page
- User logs in
- Redirects to
https://tenant.eu.auth0.com/login/callback?connection=xyz-google&organization=org_XYZ
with form dataSAMLResponse
andRelayState=https://xyz.com
- Redirects to
https://tenant.eu.auth0.com/authorize/resume?state=XYZ
- 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 - 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
- Redirects back to Googles SSO page
- Redirects again to
https://tenant.eu.auth0.com/login/callback?connection=xyz-google&organization=org_XYZ
with form dataSAMLResponse
andRelayState
but this timeRelayState
is some hashed string - Redirects again to
https://tenant.eu.auth0.com/authorize/resume?state=123
with differentstate
query string - Redirect to
https://xyz.com/auth/callback
with form datacode
andstate
- Redirect to application, logged in