Identity Server Login Fails in Safari/IOS

Viewed 872

I have setup and Angular application which is using an aspnet core + Identity Server 4 + ASP.NET Identity backend to authenticate users. I am using the npm package angular-auth-oidc-client to interact between my angular app and aspnet core app and allow users to authenticate using the Auth Code Flow + PKCE.

The authentication process works perfectly on my Windows + Chrome setup, both locally and when deployed on Google App Engine. However, when testing on IOS devices and using chrome/safari the login flow does not work. I suspect this may be down to the samesite cookie attribute as discussed here https://www.thinktecture.com/en/identity/samesite/prepare-your-identityserver/ however, I have implemeted this fix but still, users on Safari/IOS cannot login.

The user begins the authentication process, and is navigated by the identity server to my custom login page, which is part of the angular application. They then login using the form, and if successful (using SignInManager.PasswordSignInAsync) the ReturnUrl is given back to the client, where I use document.location.href = res.returnUrl to redirect the user and continue the login flow. I believe this is the step that is failing, perhaps because the cookies are not being sent with the request to the identity server? It is at this point the login page just appears to refresh for safari/IOS users. It is worth noting that my identity server and angular applications are on different sub domains.

I have included below my startup.cs file from my identity server

    public class Startup
    {
        private IWebHostEnvironment Environment { get; }
        private IConfiguration Configuration { get; }

        public Startup(IWebHostEnvironment environment, IConfiguration configuration)
        {
            Environment = environment;
            Configuration = configuration;
        }

        public void ConfigureServices(IServiceCollection services)
        {
            var migrationsAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;

            services.AddDbContext<MyDbContext>(options =>
                options.UseMySql(Configuration.GetConnectionString("MyDb")));

            services.AddIdentity<MyUser, MyRole>(options => { options.User.RequireUniqueEmail = true; })
                .AddEntityFrameworkStores<MyDbContext>()
                .AddDefaultTokenProviders();

            var identityServerBuilder = services.AddIdentityServer(options =>
                {
                    options.UserInteraction.LoginUrl = $"{Config.FrontendUrl(Configuration)}/login";
                    options.UserInteraction.ErrorUrl = $"{Config.FrontendUrl(Configuration)}/error";
                    options.UserInteraction.LogoutUrl = $"{Config.FrontendUrl(Configuration)}/logout";
                })
                .AddAspNetIdentity<MyUser>()
                .AddInMemoryApiScopes(Config.ApisScopes)
                .AddInMemoryApiResources(Config.Apis)
                .AddInMemoryIdentityResources(Config.Ids)
                .AddInMemoryClients(Config.Clients(Configuration))
                .AddOperationalStore(options =>
                {
                    options.ConfigureDbContext = builder =>
                        builder.UseMySql(Configuration.GetConnectionString("MyIdServerStore"),
                            sql => sql.MigrationsAssembly(migrationsAssembly));
                });

            if (Environment.IsProduction())
            {
                identityServerBuilder
                    .AddSigningCredential({omitted});
            }
            else
            {
                identityServerBuilder
                    .AddDeveloperSigningCredential();
            }

            services.Configure<DataProtectionTokenProviderOptions>(opt => opt.TokenLifespan = TimeSpan.FromHours(1));

            services.AddControllers();
            services.AddCustomCors(Environment, Configuration);

            services.Configure<SendGridOptions>(Configuration.GetSection("SendGridOptions"));

            services.AddTransient<IRegistrationService, RegistrationService>();
            services.AddTransient<IRoutingService, RoutingService>();
            services.AddTransient<IEmailSender, EmailSender>();
            services.AddTransient<IEmailContentService, EmailContentService>();
            services.AddTransient<IAccountService, AccountService>();

            services.AddTransient<IReturnUrlParser, Services.ReturnUrlParser>();
            services.AddTransient<IUserClaimsPrincipalFactory<SmbUser>, ClaimsFactory>();
            
            services.ConfigureNonBreakingSameSiteCookies();
        }

        public void Configure(IApplicationBuilder app)
        {
            if (Environment.IsProduction())
            {
                var forwardOptions = new ForwardedHeadersOptions
                {
                    ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
                    RequireHeaderSymmetry = false
                };
            
                forwardOptions.KnownNetworks.Clear();
                forwardOptions.KnownProxies.Clear();
            
                app.UseForwardedHeaders(forwardOptions);
            }

            app.UseExceptionHandler("/error");

            app.UseSerilogRequestLogging();

            app.UseHsts();
            app.UseHttpsRedirection();
            
            app.UseCookiePolicy();
            app.UseIdentityServer();

            app.UseCustomCors();
            app.UseRouting();
            app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
        }
    }

My Angular App login code is below:

        of(this.returnUrl)
            .pipe(
                take(1),
                switchMap((url) =>
                    this.authenticationService.logMeIn$({
                        username: this.loginForm.controls['username'].value,
                        password: this.loginForm.controls['password'].value,
                        returnUrl: url,
                    } as LoginRequest)
                )
            )
            .subscribe(
                (res) => {
                    this.success = res.success;
                    this.loading = false;
                    this.completed = true;
                    if (res && res.success) {
                        document.location.href = res.returnUrl;
                    }

                },
                () => {
                    this.success = false;
                    this.loading = false;
                    this.completed = true;
                }
            );

My cookie configuration:

        public static IServiceCollection ConfigureNonBreakingSameSiteCookies(this IServiceCollection services)
        {
            services.Configure<CookiePolicyOptions>(options =>
            {
                options.MinimumSameSitePolicy = SameSiteMode.Unspecified;
                options.OnAppendCookie = cookieContext =>
                    CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
                options.OnDeleteCookie = cookieContext =>
                    CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
            });

            return services;
        }

        private static void CheckSameSite(HttpContext httpContext, CookieOptions options)
        {
            if (options.SameSite == SameSiteMode.None)
            {
                var userAgent = httpContext.Request.Headers["User-Agent"].ToString();

                if (DisallowsSameSiteNone(userAgent))
                {
                    options.SameSite = SameSiteMode.Unspecified;
                }
            }
        }
        
        private static bool DisallowsSameSiteNone(string userAgent)
        {
            if (userAgent.Contains("CPU iPhone OS 12")
                || userAgent.Contains("iPad; CPU OS 12"))
            {
                return true;
            }
            
            if (userAgent.Contains("Safari")
                && userAgent.Contains("Macintosh; Intel Mac OS X 10_14")
                && userAgent.Contains("Version/"))
            {
                return true;
            }
            
            if (userAgent.Contains("Chrome/5") || userAgent.Contains("Chrome/6"))
            {
                return true;
            }

            return false;
        }
    }

Can anyone see anything wrong with my setup that is causing login to fail on IOS/Safari? Happy to provide more details if required

0 Answers
Related